[GraphQL] N+1問題を解決するDataLoaderの仕組みとサンプル実装
GraphQLサーバのN+1問題とは
GraphQLのQueryは、取得したいデータのノードを辿って必要なデータを一度に取得できることが強みです。しかしその一方で、GraphQLサーバのデータアクセス層ではノードが多く・深くなればなるほど、データソースへのアクセス回数が増加します。例えば以下のようなクエリでは、純粋にデータアクセスを行うと、以下のようなSQLが発行されることが想像できます。これがN+1問題です。
{ books(first: 10) { title author { name } } }
# 最初にbookを10件取得したあと SELECT * FROM books limit 10; # Authorのリゾルバでauthorを1件ずつ取得する SELECT * FROM authors WHERE id = ?; # ? = 1 SELECT * FROM authors WHERE id = ?; # ? = 2 SELECT * FROM authors WHERE id = ?; # ? = 3 SELECT * FROM authors WHERE id = ?; # ? = 4 SELECT * FROM authors WHERE id = ?; # ? = 5 SELECT * FROM authors WHERE id = ?; # ? = 6 SELECT * FROM authors WHERE id = ?; # ? = 7 SELECT * FROM authors WHERE id = ?; # ? = 8 SELECT * FROM authors WHERE id = ?; # ? = 9 SELECT * FROM authors WHERE id = ?; # ? = 10
通常のAPIであれば、テーブルをJOINして子要素を取得することを考えますが、GraphQLの場合はどのようなノードの組み合わせでQueryがリクエストされるか分かりません。全部を網羅的に取得するSQLにしてしまうと、要求されていないノードのデータまで取得することになり無駄が多くパフォーマンスが劣化しかねません。
DataLoader
そこで使用するのがDataLoaderというライブラリです。DataLoaderは、データアクセス層が効率的にデータ取得を行うための「バッチ処理機能」と「キャッシュ機能」を抽象化したものです。GraphQLサーバに限らず一般的にも使えるライブラリです。また、JavaScriptの実装はリファレンス実装とされており、他の多くの言語にも移植されています。
なお、本記事の説明はDataLoader: v2.0.0 時点の内容です。
バッチ処理関数
DataLoaderを使うには、DataLoaderのコンストラクタにバッチ処理用の関数を渡す必要があります。関数のシグネチャは以下のとおりです。K
はレコードのキー項目となる値の型、 V
は取得するレコードの型です。
type BatchLoadFn<K, V> = (keys: ReadonlyArray<K>) => PromiseLike<ArrayLike<V | Error>>;
https://github.com/graphql/dataloader/blob/master/src/index.d.ts#L73
冒頭のQueryを例にすると、以下のような関数になります。
const authorBatchLoadFn = (keys: string[]): Promise<(Author | Error)[]> => { // データソースがRDBであれば、以下のようなSQLを発行する // SELECT * FROM authors WHERE id in (keys); }
またこの関数には、守らなければならないいくつかの制約があります。
- keysの長さと、戻り値の配列の長さが等しいこと
- keysの順序と、戻り値の配列の順序が等しいこと
- データの一部が取得できない場合は、Errorオブジェクトを含めて返します
バッチ処理のスケジューラ
バッチ処理にするということは、いくつかの処理をまとめて遅延実行させるということになります。この遅延実行をコントロールする部分がスケジューラと呼ばれています。この部分がDataLoaderの最大の肝と言えると思います。
JavaScriptの実装では、ランタイムのイベントループをハックするような仕組みになっています。興味ある方はこちらをご参照ください。
https://github.com/graphql/dataloader/blob/master/src/index.js#L232
完全に理解できてないので雰囲気で説明すると、まずデータの遅延読み込みを行う#load()
関数はPromiseを返します。このPromiseはDataLoaderのインスタンス内のバッファにも格納されており、process.nextTick()
を用いて、現在のコールスタックが終了した直後に解決(実際のデータ取得が行われる)ようになっています。
このスケジューラの挙動は、オプションで変更が可能です。また単位時間ごとに処理することも可能です。JavaScriptのようなイベントループの仕組みがない言語(ランタイム環境)では、単位時間ごとに処理を実行する仕組みが採用されるているようです。
インターフェイス
DataLoaderには「バッチ処理機能」と「キャッシュ機能」のための抽象的なインターフェイスが用意されています。できることはいたってシンプルです。
インターフェイス | 説明 |
---|---|
load(key) | データを1件遅延取得します |
loadMany(keys) | データを複数件遅延取得します |
clear(key) | キャッシュを1件削除します |
clearAll() | キャッシュを全件削除します |
prime(key, value) | loadやloadManyを使ってデータを取得する前にキャッシュを作成します |
Apollo ServerにDataLoaderを組み込んでみる
簡単なサンプルプロジェクトで実際の動作を確認してみます。プロジェクト構成などは以前の記事をご参照ください。本記事ではDataLoaderに関係する箇所を中心に記載します。
https://dev.classmethod.jp/articles/apollo-server-restdatasource-and-cache/
Schema定義
以下のスキーマを定義します。books Queryからスタートして、author→books→author→...とノードをループすることができます。
type Book { title: String author: Author } type Author { name: String books: [Book] } type Query { books: [Book] }
ResolverとDataSourceの実装
Book.author
と Author.books
のリゾルバにDataLoaderを使用するように設定します。
import { ApolloServer, gql } from 'apollo-server' import { DataSource } from 'apollo-datasource' import DataLoader from 'dataloader' const books = [ { id: "0", title: 'The Awakening', authorId: "0", }, { id: "1", title: 'City of Glass', authorId: "1", }, ] const authors = [ { id: "0", name: 'Kate Chopin', }, { id: "1", name: 'Paul Auster', }, ] class LibraryAPI extends DataSource { context authorLoader bookLoader constructor() { super() // DataLoaderのインスタンスを作成 this.authorLoader = new DataLoader((ids: string[]) => this.getAuthorsByIds(ids)) this.bookLoader = new DataLoader((ids: string[]) => this.getBooksByIds(ids)) } initialize({ context, cache }) { this.context = context } async getBooks() { console.log('Call getBooks()') return books } // DataLoad関数 async getAuthorsByIds(ids: string[]) { console.log('Call getAuthorsByIds()', ids) return authors.filter((author) => ids.includes(author.id)) } // DataLoad関数 async getBooksByIds(ids: string[]) { console.log('Call getBooksByIds()', ids) return books.filter((book) => ids.includes(book.id)) } } const resolvers = { Query: { books: async (parent, args, { dataSources }, info) => { console.log("books resolver") return dataSources.libraryAPI.getBooks() }, }, Book: { author: async (parent, args, { dataSources }) => { console.log("author resolver") const author = dataSources.libraryAPI.authorLoader.load(parent['authorId']) return author } }, Author: { books: async (parent, args, { dataSources }) => { console.log("author.books resolver") const bookIds = books.filter((book) => book.authorId === parent['id']).map((book) => book.id) const authorBooks = dataSources.libraryAPI.bookLoader.loadMany(bookIds) return authorBooks } } } const server = new ApolloServer({ typeDefs, resolvers, debug: true, dataSources: () => { return { libraryAPI: new LibraryAPI() } }, playground: true, }) server.listen({ port: process.env.PORT || 4000 }).then(({ url }) => { console.log(`? Server ready at ${url}`) })
動作確認
サンプル1
Query
{ books { title author { name } } }
Result
{ "data": { "books": [ { "title": "The Awakening", "author": { "name": "Kate Chopin" } }, { "title": "City of Glass", "author": { "name": "Paul Auster" } } ] } }
サーバのログ
books resolver Call getBooks() author resolver author resolver Call getAuthorsByIds() [ '0', '1' ]
getAuthorsByIds()
がまとめて1回実行されたことが分かります。
サンプル2
Query
{ books { title author { name books { title author { name } } } } }
Result
{ "data": { "books": [ { "title": "The Awakening", "author": { "name": "Kate Chopin", "books": [ { "title": "The Awakening", "author": { "name": "Kate Chopin" } } ] } }, { "title": "City of Glass", "author": { "name": "Paul Auster", "books": [ { "title": "City of Glass", "author": { "name": "Paul Auster" } } ] } } ] } }
サーバのログ
books resolver Call getBooks() author resolver author resolver Call getAuthorsByIds() [ '0', '1' ] author.books resolver author.books resolver Call getBooksByIds() [ '0', '1' ] author resolver author resolver
getBooksByIds()
がまとめて1回実行されたことが分かります。さらに末端のauthor resolverではDataLoaderのキャッシュが効いているため getAuthorsByIds()
が実行されていない事がわかります。
サンプル3
前のサンプルでは getBooksByIds()
が1回だけ実行されましたが、そもそもbooks resolverでbooksは読み込まれているのでこれをキャッシュしてあげるほうが良さそうです。loadする前にキャッシュさせるにはprimeというインターフェイスを利用します。
async getBooks() { console.log('Call getBooks()') // 以下を追加 books.forEach((book) => { this.bookLoader.prime(book.id, book) }) return books }
そうして前のサンプルと同じQueryをリクエストするとサーバのログは以下のようになりました。
books resolver Call getBooks() author resolver author resolver Call getAuthorsByIds() [ '0', '1' ] author.books resolver author.books resolver author resolver author resolver
getBooksByIds()
が実行されなくなったことが確認できました。
おわりに
DataLoaderを使うことでデータアクセス層で発生するN+1回のリクエストが解消されることが確認できました。スケジューラの挙動が理解できていないので、何かわからんけどうまくやってくれていてすごい という感想になってしまいます。悔しいのでもう少し調べたいと思います。